feat(spec): every metadata.endpoints.* switch gates exactly the face its name states, and the whole-store operations get their own key maintenance - #16243
Conversation
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 25 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 132 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 45d7688eb07f4f7221152689d6dfb18722f97dc8 && git checkout 45d7688eb07f4f7221152689d6dfb18722f97dc8
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d53335ecbbb9fc2c4d50c2f7cbdce30df7d2eea2 57b8e451ac63dd535adb49d034cc0b7e3443f1ae && git checkout -B drift-repro d53335ecbbb9fc2c4d50c2f7cbdce30df7d2eea2 && git merge --no-ff 57b8e451ac63dd535adb49d034cc0b7e3443f1ae
node scripts/docs-audit/affected-docs.mjs --json d53335ecbbb9fc2c4d50c2f7cbdce30df7d2eea2
|
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…r system-context Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Contract review (clause ②) — PASS on content · NOT LANDABLE (red CI, real, this PR's) — PR #16243 at head
|
⛔ This PR is RED, and the failure is caused by this diff — not by infrastructure. My ACCEPT missed it because I never read this PR's CI
The failure
Why it is this diff's, and why it matters more than a count
⭐ And it lands on the design choice I singled out for praise in my ACCEPT:
That argument is still right about the switch. But the same move that binds the gate to the registration also removes the registration from the census's sight. I praised one half of the consequence and never looked for the other, because I reviewed the report, the carriers and the ruling — and ⛔ never opened this PR's check runs. What has to happen — ⛔ and none of the three is "update the number to 73"Whoever takes the repair must first answer: are those 7 routes still reachable by the authz probe through the helper, or not?
⛔ Do not touch the pin before that question is answered by measurement, and ⛔ do not treat the assertion as a rebase artefact. Status corrections
Generated by Claude Code |
…emRoute spelling The per-item family's later members register through a switch-carrying local helper instead of a direct `this.routeManager.register(` call. The census rule knew only the direct spelling, so it read population 73 / reachable 12 against a recorded 80 / 19 and the Dogfood Regression Gate went red. Re-recording 73/12 was the wrong repair: those 8 routes are still mounted and still registered inside `registerMetadataEndpoints`, so the lower number would have ratified a false population and encoded a 7-route blind spot in the census named for finding them. The rule now counts both spellings, excluding the helper's own forwarding call so it is not double-counted: 72 direct + 8 helper-routed = 80, and 11 + 8 = 19 reachable. Reachability was checked before the count was widened. `registerPerItemRoute` reads `this.routeManager` at call time and every call site is inside `registerMetadataEndpointsInner`, which runs under the anonymous-deny `guardedRouteManager` swap — so the helper hides nothing from the probe. That is now measured rather than argued: rest-meta-auth.test.ts drives an anonymous `GET /meta/:type/:name/history` to 401 with the history read never reached. Both halves of the new rule carry exact positive controls so neither can go silently to zero. Also completes the changeset's BREAKING paragraph: `MetadataEndpointsConfigParsed` gained a required `maintenance: boolean` on the parsed (output) side — an ADR-0087 D8 compiler-carried narrowing that was implemented but not written down. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T6HeZvT9wdSJD1ZxJb5Eno
落地前检三条,逐条读数 —— ①② 已备,③ 全绿。挂 auto-merge
① 卡上达档 PASS —— 已备,刚补上
② 双载体已清 —— 已备,直接 API 读回
⛔ 机读
⛔ 我没有为了把机读弄绿而去 #15854 补一条 限位腿要确立的事实本身是齐的,而且早于问题存在:裁决 ③ 每一项检查全绿 —— 38/38,零 cancelled⭐ 本车道今天第一张六个分片各自 success 且各自发布 attestation 的 PR。 我把那三项跑最久的点开确认过,不是只看汇总:
38 项:36 success + 2 skipped( 复核发现的处置五条 non-blocking,⛔ 都不在本 PR 里改、⛔ 不扩面。第 2 条(消融的 LOW 腿:helper 收尾 落地挂 auto-merge,走合并队列。⛔ 不手工合并、⛔ 不绕队列。
Generated by Claude Code Generated by Claude Code |
Fixes #15542
Closes #15854
Implements the maintainer ruling recorded on #15542 (comment 5557095147, director seat, batch #53): an
endpoints.*switch gates exactly the face its name states, reads and writes alike. Both halves land here because they are one re-partition of one surface — #15854 is this card's converse mismatch, one switch over.#15854 closes with this PR rather than being merely referenced by it, by its own ruling (comment 5557098503, item 1): "both halves are one
domain:specPR, and this card closes when that PR lands." Itspm:retriageis scoped by item 2 to the ROUTING question only — re-route todomain:spec, or close as a duplicate half — and every branch of that question still ends with the card closed on landing. So the body saysCloses #15854; it deliberately does not also carry a part-of declaration, which the "Part-of PR must not also close its card" gate forbids pairing with a close.⛔ Draft on purpose, and it must stay that way. Clause-② is
yes;claude-fable-5-1was exhausted platform-wide when this was dispatched, so construction ran atclaude-opus-5under the quota-exhaustion exemption. Review does not take that exemption. Do not flip ready and do not enqueue until an at-tierneeds:contract-reviewPASS exists.The defect, in both directions at once
RestServerConfig.metadata.endpointsdeclared three switches, eachdescribe()naming exactly one route, and each gated a different set:typesGET /metaGET /metaandGET /meta/types, one handler, two paths (deliberate)itemsGET /meta/:typeGET /meta/diagnostics,GET /meta/_draftsand thePOST /meta/_migrate-storedwrite dooritemGET /meta/:type/:namePUT/DELETE, nor the history family, which answered toapi.enableMetadataaloneSo an operator who switched off a listing read they considered chatty silently unmounted a migration write door; and an operator who closed the per-item surface left its writes mounted. Neither is a liveness defect — all three keys were genuinely read — which is why no ADR-0049 census could flag either: what drifted was each key's radius against its own documentation, the axis the ledger structurally cannot see.
The code's own routing comment already separated "whole-store operations (
/diagnostics,/_drafts,/_migrate-stored)" from "the per-type list (/:type)". The taxonomy was ahead of the switch surface. This makes the switches equal to it.What lands
/meta)typesGET /meta,GET /meta/types— unchangeditemsGET /meta/:type— and nothing elseitemGET/PUT/DELETE /meta/:type/:name,/references,/layers,/history,/audit,/diff,/published,/publish,/rollback,GET /meta/book/:name/tree— 12maintenancetrue—GET /meta/diagnostics,GET /meta/_drafts,POST /meta/_migrate-stored— 3packages/spec/src/api/rest-server.zod.ts— the new key, and all fourdescribe()strings rewritten to enumerate what they gate. The radius is the contract now, not a sample of it.packages/rest/src/rest-server.ts— the three whole-store gates readmaintenance; the per-item family's later members (PUT,DELETE,history,audit,publish,rollback,diff,published) go through a new localregisterPerItemRoute, which carries the sameendpoints.itemswitch.packages/rest/src/rest-config-mount-table.pin.test.ts— the [finding] The MOUNT half of everyRestServerConfigswitch is unpinned — the tests assert what a switch normalizes to, never that the route leaves the table #15544 mount-absent pins re-stated on the new radii, not deleted (ruling item 5). Each key keeps a row asserting the exact set of routes that leaves when it isfalse, in both directions, each with its presence twin. The§0exhaustiveness count moves 19 → 20 with the new switch.packages/rest/src/rest-meta-auth.test.ts— a new anonymous-deny pin on a helper-routed per-item route (see the repair-round section).packages/qa/dogfood/test/authz-probe-blind-spot.census.ts— the authz blind-spot population rule learns theregisterPerItemRoute(spelling (see the repair-round section).packages/spec/liveness/metadata_endpoints.json— amaintenancerow, and radius notes onitems/itemrecording that the move was ruled rather than drifted.packages/spec/src/migrations/entries/semantic/18.metadata-endpoints-switch-radius-repartitioned.ts— the ADR-0087 D3 prescription (see below).docs/qa/platform-checklist/— see below; the acceptance clause is kept and re-pointed, never retired.Why
registerPerItemRouteis a call and not one moreifblockTwo reasons, neither cosmetic. (1) No single brace pair contains exactly the right set: the later members of the per-item face are spread over ~1200 lines with
GET /meta/object/:name/state/:field— deliberately not part of that face — sitting among them. (2) A gate that travels with its registration cannot be inherited or shed by moving a route past a brace, which is precisely how this switch came to gate four reads and none of its own writes. It readsthis.routeManagerat call time, deliberately:registerMetadataEndpointsswaps in the anonymous-deny wrapping registrar for the duration of the method, so a captured reference would register past that gate.A secondary benefit is diff hygiene on a busy cross-lane file: wrapping those eight registrations in
ifblocks would have re-indented ~1200 lines ofrest-server.tsfor no behavioural gain.One route deliberately left alone
GET /meta/object/:name/state/:field— the object FSM read, addressed by object name rather than by:type/:name— is in no per-family switch and still answers toapi.enableMetadataalone. The ruling's enumeration does not name it, so moving it under a switch would be a fresh decision, not a tidy-up. It is called out in the registrar docblock and in the pin table so the next reader does not read its absence as an oversight.BREAKING — priced and accepted (ruling item 6)
For a programmatic embedder that authors
RestServerConfig.metadata.endpoints, the mounted table moves for two keys, in opposite directions:items: falseremoves one route instead of four. An embedder relying on it to close the whole-store family regains all three routes, the write door among them, unless it also setsmaintenance: false. One line restores the old table:endpoints: { items: false, maintenance: false }.item: falseremoves twelve instead of four. An embedder relying on it to close only the per-item reads losesPUT,DELETEand the history family too. No key restores them — the per-item face is one face by this ruling.MetadataEndpointsConfigParsednarrows:endpointsgains a REQUIRED membermaintenance: boolean.maintenanceisz.boolean().default(true)— optional on the way in, always present on the way out — andMetadataEndpointsConfigParsedisz.inferof the schema, the OUTPUT side. Code that hand-builds one of these objects (a fixture, a helper returning the parsed shape, asatisfiesliteral) stops compiling withTS2741: Property 'maintenance' is missing. This one is compiler-carried, the ADR-0087 D8 class, and that is the good case: loud, at build time, no changelog reading required. In-repo consumers of the type: none — measured against a probe compiled from the rebuilt declaration, not assumed.Measured blast radius today is zero:
RestServerConfigis reachable from no shipped boot path (#15543 —os servepins the config and the dev plugin passes none), so the affected population is programmatic embedders only. That is exactly why it lands now rather than after a boot path starts authoring it.ADR-0087 disposition: a D3 semantic migration is REGISTERED; no D2 conversion. The changeset carries
adr-0087: registered metadata-endpoints-switch-radius-repartitionedandcheck-adr-0087-registrationaccepts it as new in this diff. There is nothing for the conversion layer to convert — no key is renamed, removed or retyped, and aRestServerConfigis plugin TS configuration, never a stack collection member and never asys_metadatarow (theRestServerConfig.openApi31precedent, #4579), so no rehydration seam sees it. But an embedder is owed a prescription, because the channel that would otherwise reach them is blind for the RADIUS change: every authored key is an optional boolean, so{ items: false }still compiles and still parses and simply mounts a different table. (The parsed-type narrowing above is compiler-carried, but it catches only code that hand-builds the OUTPUT type — it cannot reach the embedder who authored{ items: false }and now silently gets three routes back.) A D2 conversion could not decide the question either — rewriting{ items: false }to{ items: false, maintenance: false }preserves the old mounts but presumes an intent the author never expressed, and leaving it alone re-mounts a write door. That judgment is delegated, which is exactly what D3 is for.The checklist item is kept satisfiable, not made vacuous (ruling item 7)
docs/qa/platform-checklist/areas/api-backend.json→api-backend.rest-metadata-config-contractis at revision 2. Its acceptance clause still requires a run to ENUMERATE each switch's real radius from a route-table diff rather than trust thedescribe(); what changed is the expected sets, plus a fourthmaintenancestep and the both-directions rule (a switch that grows a route is as much a FAIL as one that loses one). Left stale it would not have gone vacuous — it would have produced a false FAIL against the new radii, which is worse.FOLLOW-UPS.md§10b E1 records the ruling and points at revision 2; §10b E3's "nineteen gates pinned" is refreshed to twenty.Declared scope increments (for the PM)
Three paths outside the claim's declared file surface, all named here rather than folded in silently:
docs/qa/platform-checklist/**— required by ruling item 7, as above. Not optional: the alternative is a checklist item that fails against the tree it is meant to measure.packages/spec/liveness/metadata_endpoints.json— a new declared key needs its ADR-0049 ledger row, anditems/itemcarry radius notes that were measured statements about the old gates.packages/spec/src/migrations/entries/semantic/+ the generatedregistry.ts—check-adr-0087-registrationrefuses a**BREAKING**changeset with no disposition marker, andnot-required (no-migration-prescription)is unavailable to a body that (correctly) prescribes one. Registering was the honest answer, not a wider scope.Plus
content/docs/permissions/system-context.mdx, which is line-anchored intorest-server.tsand was re-anchored mechanically bycheck-system-context-census --fix(10 anchors, no prose change).One bounded in-place fix in the same docblock this card rewrites:
MetadataEndpointsConfigSchema's@exampleadvertisedendpoints: { types, objects, fields }— two keys the schema has never declared. It is the same declared-versus-real class the card is about, sitting three lines above the block being changed, and it now shows the four real keys.Repair round at
57b8e451ac— the authz blind-spot censusDogfood Regression Gate (3/3)was red on the previous head484601894(run34019739422, job101450083462, failed 07:50:38Z), and it was caused by this diff.authz-probe-blind-spot.census.tsrecordsrest-server.tsat population 80 / reachable 19 / blind spot 61, under the rule "this.routeManager.register(call sites; reachable = those insideregisterMetadataEndpoints".registerPerItemRoutemoved 8 sites off that spelling and added 1 back inside the helper, so the old rule read 73 / 12.⛔ 73 / 12 was NOT re-recorded. Those 8 routes are still mounted and still registered where they were; only the spelling of the call changed. Writing the lower number down would have ratified a false population and encoded a 7-route hole in the very census that exists to find routes the probe cannot see — the failure it is named for, committed by its own record.
First: are the 8 helper-routed routes still reachable by the probe? Yes — checked before the count was widened, because if the helper hid them the repair would belong in
rest-server.tsinstead.registerPerItemRoutereadsthis.routeManagerat call time, and all 8 call sites are lexically insideregisterMetadataEndpointsInner, whichregisterMetadataEndpointsruns withthis.routeManagerswapped to the anonymous-denyguardedRouteManagerand restored in afinally. So a helper-routed registration is wrapped identically to the 11 remaining direct sites, and the umbrella keymeta:rest-server.ts:registerMetadataEndpointscovers it unchanged. That is now measured, not argued: a new case inrest-meta-auth.test.tsdrives an anonymousGET /meta/:type/:name/history— a helper-routed route — to401 UNAUTHENTICATEDwithhistoryMetaItemnever called. Before this PR that whole half of the surface had no test proving it is anonymously denied.The rule now counts both spellings, excluding the helper's own forwarding call so it is not double-counted. The decomposition: 72 direct + 8 helper-routed = 80; 11 + 8 = 19 reachable.
blindSpotstays 61 and both repo-wide totals are unchanged. The controlthis.routeManager.register(is re-recorded 80 → 73 (it is a control on the file, not the population) and two new exact controls —registerPerItemRoute(= 8 andconst registerPerItemRoute == 1 — guard the new half so neither can go silently to zero.Ablation — the widened rule still discriminates. A rule that counts everything is as useless as one that counts nothing, so both halves were ablated independently against the real
deriveProbeFileCensus(), each proved on disk and restored fromHEAD:Leg B is the load-bearing one: it is the new half, and removing one helper-routed registration drops the population by exactly one. Leg A shows the widening did not turn the old half into a count-everything. Leg C shows the forwarder slice fails loud rather than quiet if the helper moves out of shape. No build step is involved and none is owed:
deriveProbeFileCensus()readsrest-server.tsas text throughreadFileSync, imports nothing from@objectstack/restand resolves through nodist/— so there is no stale-distfalse-green risk here, and the readings above moved with nothing rebuilt.The changeset's BREAKING paragraph also gained the
MetadataEndpointsConfigParsednarrowing (third bullet above), which was implemented but not written down.Verification
Figures for the contract increment were measured at
484601894; the repair-round figures at57b8e451ac, the final commit.Repair round —
bash scripts/pm/os-verify-lock.sh -c …, reading each run's ownVERDICTline:Baseline red reproduced locally before the fix, matching CI byte for byte:
expected 73 to be 80pluscontrol "this.routeManager.register(" moved in packages/rest/src/rest-server.ts,Tests 2 failed | 31 passed (33).packages/rest'stypecheckdoes not compile its test layer —tsc --noEmit --listFilesnamesrest-meta-auth.test.ts0 times (pre-existing:check:test-typecheckreports the rest test layer at 0 files). The new case is therefore executed by vitest (green) but not type-checked.packages/qa/dogfood's tsc does compile the census —--listFilesnames it once — so that edit is type-checked.Contract increment, at
484601894—pnpm --filter @objectstack/spec --filter @objectstack/rest run test,VERDICT command-exit 0:Reverse verification — the mount-table pin can actually fail. With the implementation committed, the
_migrate-storedgate was mutated back toendpoints.itemsand the pin re-run:Direction observed, stated rather than assumed: two rows reddened, not one —
metadata.endpoints.itemsgrew a route andmetadata.endpoints.maintenancelost one. That is the set-difference-in-both-directions property the pin is built on, demonstrated rather than asserted.Cross-package type check — the declaration really was rebuilt. A probe compiled against
@objectstack/spec's rebuiltdist/*.d.tsfrom insidepackages/rest:So the green typecheck above read a fresh declaration, not a cache. The probe file was removed and the removal verified.
Gates — restated accurately, correcting the previous round. The earlier body claimed "102 families derived; all 102 run; authored to green". The 102 count and the reconciliation were real, but the conclusion drawn from them was not: the derivation does not reach
packages/qa/dogfood, so a full derived-family run was structurally incapable of running the gate that was red.The derived list is still 102 on the current change set, unchanged by the repair (it adds no new path). Re-run this round, all
EXIT=0:check:nul-bytes,check:cross-package-test-inputs,check:test-source-alias,check:changeset-gate-self-tests,check:objectui-changeset,check:type-check-coverage— the families the repair's own two files touch. The remaining families were run at484601894on a file surface the repair does not change. CI is the authority on the full farm, and the census gate is now green locally; its CI verdict on this head is what settles it.The derivation gap is filed as #16285, not fixed here.
dispatch-gatesalso reportedSTALE TREE — derived from a tree at least 34 commit(s) behind origin/main, and 12 file(s) it derives from CHANGED across that range. The family list may therefore be short of families added onmainsince this branch forked; that half is the PM's to read against real CI, not something this branch can settle locally.Lint — no narrowing was needed: the repo-wide authority ran whole.
pnpm lint(eslint . --no-inline-config)EXIT=0in 58s at484601894; the repair touches two files and no lint configuration.Collision check, read as instructed:
scripts/check-single-claim-paths.mjsdeclares exactly one single-writer path,.objectui-sha.packages/rest/src/rest-server.tsis deliberately not on that list (the header's measurement rejects merely-hot files), so that gate can give no signal here — it passes, and its pass is not a clearance. The zero-quota substitute:git ls-remote --heads origin(1182 heads) matches no branch namingrest-server,metadata-endpoint,15854,15544or15851.Known fleet CI defect, not this PR's: if
Test Core (1/6)showscancelledat a 30-minute timeout while the aggregateTest Corereports success, that is #16173 (amplifier #16157). ⛔ Do not re-run it, do not raise the timeout, do not touchscripts/test-shard-timings.json. On484601894it did not fire:Test Core (1/6)succeeded in 24m03s (07:40:21Z → 08:04:24Z), read from the check runs directly.Generated by Claude Code